iT邦幫忙

2026 iThome 鐵人賽

DAY 10
0
AI Engineering

30 天打造 AI 後端:從 LLM、RAG 到 AI Agent系列 第 10

[Day 10] 五萬向量實測 HNSW:快 32 倍的代價,是 15 題錯 3 題

  • 分享至 

  • xImage
  •  

同一份索引、同一批題目:精確檢索答對 10/15,HNSW 用預設參數答對 7/15。
掉的那三題,top-1 不是「排錯的條文」——是三個根本不存在的隨機向量。
而你為此換到的,是每題 25ms 變 0.77ms。

本篇你會帶走

  • 50,059 塊向量的實測數字:精確掃描(Flat)與近似檢索(HNSW)差 5~43 倍,看你把 efSearch 開多大
  • 「近似」的代價長什麼樣子:不是慢一點的正確,是偶爾完全走丟——掉題解剖與 recall 曲線,以及代價歸零的位置
  • 0 元把索引從 59 塊撐到 5 萬塊的合成資料做法,和為什麼不能偷懶用純亂數

一、為什麼今天做這件事

昨天欠的債:Day 9 文末我說「五萬塊向量還逐一算相似度就不行了,該讓向量資料庫上場」。今天先讓向量資料庫的心臟上場——向量索引。metadata、持久化、過濾那些讓它配得上「資料庫」三個字的東西,明天再來。

Day 8、Day 9 的檢索是同一招:把問題向量跟索引裡每一塊算一次相似度,排序取最高。59 塊向量這樣掃,一題不到 1ms,完全沒有問題——問題是這句話裡的「59」。真實系統的知識庫是幾萬、幾十萬塊起跳。掃描是 O(N),N 放大一千倍,延遲就放大一千倍。

所以今天的實驗只做一件事:把 N 灌大,量給你看

語料還是那份 59 條的虛構《員工工作規則》(技術示範用,非任何公司實際規章),15 題測試題也原封不動——變的只有索引規模:我用 0 元的合成向量,把索引從 59 塊灌到 50,059 塊。

二、做法/實作

引擎:FAISS,一行裝好

pip install faiss-cpu

FAISS 是 Meta 開源的向量索引庫,幾乎所有向量資料庫的檢索核心都是它或它的同類。過去它在 Windows 上是出名的難裝,但 2026 年這件事過期了——v1.14.2 起官方直接發 PyPI wheel,三大平台都是一行裝好。

今天用它的兩種索引,同一套 API、同一份資料,只換索引型別——這樣量出來的差異才只屬於演算法,不摻雜實作差異:

def build_flat(vectors):
    """精確:內積暴力掃描(向量已正規化,內積=餘弦相似度),結果保證正確"""
    index = faiss.IndexFlatIP(vectors.shape[1])
    index.add(vectors)
    return index

def build_hnsw(vectors, m=32, ef_construction=200):
    """近似:HNSW 圖。查詢走圖導航而非全量掃描,快,但不保證找到真正的最近鄰"""
    index = faiss.IndexHNSWFlat(vectors.shape[1], m, faiss.METRIC_INNER_PRODUCT)
    index.hnsw.efConstruction = ef_construction
    index.add(vectors)
    return index

兩種索引查詢都是同一行:index.search(question_vector, top_k)

用文字圖解說差在哪:

Flat(精確)                HNSW(近似)
問題向量                    問題向量
  │                          │
  ▼                          ▼
跟全部 50,059 塊             從圖的入口點出發,
逐一算內積 ──── O(N)         沿著「越走越近」的邊
  │                          跳躍前進 ──── 近似 O(log N)
  ▼                          │
排序取 top-k                 ▼
【保證正確】                 走到局部最近的鄰域取 top-k
                            【可能走丟,不保證正確】

HNSW 查詢期有一顆重要的旋鈕 efSearch:導航時同時追蹤幾條候選路徑。開越大越不容易走丟(越準),但也越慢。FAISS 的預設值是 16——記住這個數字,等下它會出事。

0 元灌出 5 萬塊:隨機質心+擾動

59 條條文只有 59 個向量,要 5 萬塊怎麼辦?拿真文本去算 embedding 要花錢也要等;我用合成的:

centroids = rng.standard_normal((500, 1536), dtype=np.float32)   # 500 個隨機質心
points = np.repeat(centroids, 100, axis=0)                       # 每個質心複製 100 份
points += 0.35 * rng.standard_normal((50000, 1536), dtype=np.float32)  # 加擾動
faiss.normalize_L2(points)

兩個設計重點:

  1. 不能偷懶用純亂數。 高維空間裡的隨機向量彼此近乎正交、沒有任何叢集結構,是 HNSW 圖導航的最壞情況——量出來的數字不能代表真實 embedding 的行為。「質心+擾動」讓資料有叢集,才像一份真的語料。
  2. 固定 seed、不落地存檔。 50,000 × 1536 的 float32 有 300 MB,存檔進 repo 是災難;固定 seed 每次重新生成,1 秒生完,clone 下來跑出一模一樣的資料。

59 條真實向量放在 id 0~58,跟 5 萬個合成向量疊在同一份索引裡。

三、實驗設計:同一把尺

  1. 題目哪來:Day 9 的同一組 15 題(10 題沿用+5 題新增、直白/口語都有),一題都沒動。今天比的是引擎不是切法,題目變了數字就不可對照。
  2. 判定標準:兩把尺,回答不同的問題。
    • Day 9 的尺:top-1 的字元範圍是否與預期條文範圍重疊——回答「答案對不對」。top-1 若是合成向量,直接算沒中。
    • recall@k:以 Flat(精確)的 top-k 為標準答案,量 HNSW 的 top-k 跟它重合多少——回答「近似跟精確差多遠」。
  3. 延遲怎麼量:只計 index.search() 本身,問題向量事先算好——量的是索引,不是 embedding API 往返。每個設定 15 題 × 20 次=300 個樣本,取中位數與 p95。
  4. 尺的偏誤,自己先講:干擾向量是合成的、跟真實語意無關,所以本篇只能主張「資料變多讓掃描變慢、讓近似掉題」,不能主張「資料變多讓檢索變不準」。另外,延遲是在筆電**電池供電(省電降頻)**下量的,接電源整體會更快——絕對值請以你的機器為準,相對倍率與掉題結論不受影響。

如果只跑 demo 不量測會錯過什麼?demo 用預設參數查一題,0.8ms 就回來了,結果看起來也像模像樣——你會直接上線。掉題這件事,不逐題對答案根本看不見

四、結果

先看總表(完整逐題明細在 repo 的 experiment_result.md):

表一:單題查詢延遲(50,059 塊、1536 維)

引擎 中位數 p95 相對 Flat
Flat(精確,單執行緒) 24.58 ms 37.47 ms 1x
HNSW efSearch=8 0.566 ms 0.992 ms 43x
HNSW efSearch=16(預設) 0.771 ms 2.351 ms 32x
HNSW efSearch=64 2.007 ms 4.512 ms 12x
HNSW efSearch=256 5.062 ms 6.946 ms 5x

表二:HNSW 的代價——recall 與命中率

efSearch recall@1 recall@10 Day 9 題組命中
Flat(對照) 1.00 1.00 10/15
8 0.73 0.73 7/15
16(預設) 0.80 0.80 7/15
32 0.87 0.87 8/15
64 1.00 1.00 10/15
128 1.00 1.00 10/15
256 1.00 1.00 10/15

發現一:暴力掃描其實還沒死

先誠實講一個反直覺的結果:5 萬塊向量,精確掃描一題也才 25ms。如果我在這裡收工,結論會是「Day 9 的預告嚇唬人,根本不用上 ANN」。

但 25ms 是單題視角。換成服務視角:一顆核心每秒只能服務 40 題,而且 O(N) 意味著資料到 50 萬塊時變成 250ms——使用者按下送出後明顯卡一下的等級。逼你上 ANN 的從來不是「一題太慢」,是 QPS × 資料成長的乘積。

發現二:預設參數的陷阱

efSearch=16 是 FAISS 的預設值。表二那一行:recall 0.80、15 題掉 3 題。

掉的三題長這樣(完整解剖在 experiment_result.md):

題目 預期 HNSW top-1 變成
病假連續請幾天以上需要附診斷證明? 第 24 條 合成向量 #8000
每月加班時數上限是多少小時? 第 18 條 合成向量 #30339
國內出差住宿費每晚上限是多少? 第 40 條 合成向量 #15453

注意錯的方式:不是第二名擠掉第一名,是回傳了一個跟問題毫無關係的隨機向量。HNSW 走丟的時候,交出來的是「它走得到的範圍內最好的」,而不是全域最好的。在 RAG 裡這意味著:LLM 拿到的 context 不是次佳條文,是垃圾——而它還是會一本正經地用這份垃圾回答。

而且這三題在 Flat 底下全對,其中兩題(第 18 條、第 40 條)還是全場相似度最高的題目(0.72、0.73)——掉題跟「題目難不難」無關,跟圖的形狀有關

發現三:代價有歸零點,但位置要自己量

efSearch 開到 64:recall 1.00、15 題與 Flat 全部一致,延遲 2ms——仍比精確掃描快 12 倍。在這份資料上,「準」與「快」可以同時要。

但重點是那句「在這份資料上」。歸零點是量出來的,不是查文件查出來的:你的向量分布不同、規模不同,你的 64 就在別的位置。這也是為什麼今天的實驗腳本值得留著——換上你自己的資料,掃一輪 efSearch,找到自己的歸零點再上線。

一個交代:合成向量沒有污染答案

有人會問:灌了 5 萬個假向量,答案會不會被假向量搶走?實驗驗證過:15 題的 Flat 精確結果裡,合成向量在 top-10 的 150 個位置中出現 0 次;Flat 命中率 10/15,跟 Day 9 用 59 塊索引時一模一樣。換引擎、灌干擾,精確檢索的答案一個都沒變——所以表二裡 HNSW 掉的每一題,都只能算在「近似」頭上。

五、決策速查表

情境 建議 代價
向量數萬級、單人或低 QPS Flat 就好,別急著上 ANN 每題幾十 ms
十萬級以上、或 QPS 上得來 HNSW,但用自己的資料掃 efSearch 找歸零點 建圖幾分鐘、記憶體多一份、沒調參會掉題
準確度不能妥協(法遵、醫療) Flat,或 efSearch 開大並持續監控 recall 延遲、算力
只是想 demo 隨便,但別把 demo 的預設參數帶上線 上線後才發現的掉題

六、這次花了多少錢

  • API 計費:59 條條文+15 題問題的 embedding,首次執行 13,619 tokens ≈ 0.000272 美元 ≈ 新台幣 0.0084 元;之後重跑全部命中本機快取,0 元
  • 50,000 個合成向量:0 元(本機生成,固定 seed 可重現)
  • 額外成本是時間與記憶體:HNSW 建圖約 2~4 分鐘(刻意單執行緒——多執行緒建圖每次結果不同,數字就不可重現)、全程峰值記憶體約 1.4 GB

小結

近似檢索的代價不是「慢一點的正確」,而是「快非常多、但偶爾完全走丟」——而預設參數不會告訴你它走丟了。

丟個問題給你:你現在線上那套 RAG,向量庫的 efSearch(或 efsearch_k,每家名字不同)是多少?是量出來的,還是預設值?歡迎留言聊聊。

明天 Day 11:今天這份索引重開機就沒了、加一塊向量要整棟重建、想按部門過濾條文?做不到。這些讓「索引」升級成「資料庫」的東西——metadata、持久化、過濾——昨天承諾的向量資料庫,明天真的上場:Chroma。

完整程式碼

GitHub:https://github.com/wp900622/TrustRAG (day10_vector_index/,clone 即可重現全部數字)

參考資料:

  • FAISS 官方文件:https://faiss.ai/
  • HNSW 原始論文:Malkov & Yashunin, "Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs" (2016)

上一篇
[Day 9] 我讓三種 Chunking 切法同場競技:最被看好的墊底,命中率還會騙人
下一篇
[Day 11] 換上 Chroma:過濾、持久化、增量都有了,但預設參數照樣掉題
系列文
30 天打造 AI 後端:從 LLM、RAG 到 AI Agent26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言